當一個 provider 需要根據多個依賴動態建立時,useFactory 是 NestJS 常見的做法之一。這種寫法看似直觀,背後卻隱藏著一個容易被忽略的位置對應陷阱。
這種錯位最棘手的地方在於它的高度靜默性:tsc 編譯通過、應用程式也能正常啟動,甚至回傳的結構都完全合規——唯獨裡面的資料內容無聲無息地對調了。
如果沒有相應的測試防線,這類錯誤很容易一路溜進正式環境,直到發出異常請求時才被發現。
我們先從一個簡單的範例來看,假設我們需要把兩個獨立的設定值組合為 PostConfig 物件,並提供給 Controller 注入使用。
首先定義這份設定檔的介面:
// post-config.interface.ts
export interface PostConfig {
apiBaseUrl: string;
defaultAuthor: string;
}
然後在模組裡註冊兩個原始設定值,再用一個 factory 把它們組起來:
// post-config.module.ts
@Module({
controllers: [PostConfigController],
providers: [
{
provide: 'POST_API_BASE_URL',
useValue: 'https://posts.example.test',
},
{
provide: 'DEFAULT_AUTHOR',
useValue: 'YUN',
},
{
provide: 'POST_CONFIG',
// ❌ 地雷:Nest 依照 inject 陣列的位置傳值,不會依參數名稱配對。
inject: ['DEFAULT_AUTHOR', 'POST_API_BASE_URL'],
useFactory: (apiBaseUrl: string, defaultAuthor: string): PostConfig => ({
apiBaseUrl,
defaultAuthor,
}),
},
],
})
export class PostConfigModule {}
Controller 用字串 token 注入:
// post-config.controller.ts
@Controller('posts/config')
export class PostConfigController {
constructor(
@Inject('POST_CONFIG')
private readonly postConfig: PostConfig,
) {}
@Get()
getConfig(): PostConfig {
return this.postConfig;
}
}
這段程式碼能順利通過型別檢查並正常啟動。
接下來,我們實際發個請求來驗證結果:
curl http://localhost:3000/posts/config
{
"apiBaseUrl": "YUN",
"defaultAuthor": "https://posts.example.test"
}
兩個設定值完全顛倒了。
apiBaseUrl 誤塞了作者名稱,而 defaultAuthor 則拿到了網址。如果其他模組直接拿這份設定去組合 API 路徑,便會向 https://YUN/... 發出錯誤請求,且這種錯誤通常要等到實際呼叫時才會暴露。
inject 是位置參數清單,不是具名對應表NestJS 的官方文件給出了非常明確的說明:
Nest will pass instances from the
injectlist as arguments to the factory function in the same order.——NestJS 官方文件(Custom providers)
可以把這個機制理解成兩個步驟:
inject 陣列裡的每一個 token,取得對應的實例,組合成一個陣列。useFactory 函式。若把這個邏輯套回剛才的範例:
| 索引 | inject 的 token |
解析出的值 | 傳入的參數位置 |
|---|---|---|---|
0 |
'DEFAULT_AUTHOR' |
'YUN' |
第 1 個參數(apiBaseUrl) |
1 |
'POST_API_BASE_URL' |
'https://posts.example.test' |
第 2 個參數(defaultAuthor) |
實際發生的事情等同於這一行:
useFactory('YUN', 'https://posts.example.test');
理解這個機制後問題就很明顯了:useFactory 函式裡的參數名稱只是寫給開發者看的。換句話說,Nest 並不存在「根據參數名稱尋找同名 provider」這套機制。
這個錯位之所以能輕鬆逃過型別檢查,原因在於 inject 陣列與 useFactory 參數之間的對應關係,在 NestJS 的型別定義中完全是解耦的:
// @nestjs/common 的型別定義
export interface FactoryProvider<T = any> {
useFactory: (...args: any[]) => T | Promise<T>;
inject?: Array<InjectionToken | OptionalFactoryDependency>;
}
export type InjectionToken<T = any> =
| string | symbol | Type<T> | Abstract<T> | Function;
如果我們仔細來看,會發現原本仰賴的三道潛在防線會依序失守:
useFactory 在官方型別定義中被宣告為 (...args: any[]) => ...。因此,即使你在實作時手動標註了 (apiBaseUrl: string, defaultAuthor: string),TypeScript 也只會把它當成一般函式,根本不會去拿它的參數簽名與 inject 陣列做長度或順序的比對。InjectionToken<T>,但 FactoryProvider 並沒有利用這個泛型,把 inject 中每個 token 的解析型別與 useFactory 對應位置的參數型別串起來。尤其使用字串或 Symbol token 時,token 本身在 runtime 只是一個識別值,無法表達「這個 token 解析後一定是某個特定型別」。string,在靜態型別系統眼裡完全相容,根本無法判斷「網址」與「作者名稱」在語意上的差異。inject 陣列與 useFactory 的參數順序最直接的解法,就是確保 inject 陣列裡的 token 順序與 useFactory 的參數列完全一致:
{
provide: 'POST_CONFIG',
inject: ['POST_API_BASE_URL', 'DEFAULT_AUTHOR'],
useFactory: (apiBaseUrl: string, defaultAuthor: string): PostConfig => ({
apiBaseUrl,
defaultAuthor,
}),
}
將順序對齊後,我們再次打一次 API 驗證,這時回傳的 JSON 資料欄位與數值都正確了:
{
"apiBaseUrl": "https://posts.example.test",
"defaultAuthor": "YUN"
}
特別提醒,這個解法是一個必要的修正,不是最強的防錯手段。
當多個值本來就描述同一個概念時,與其把它們拆成多個基礎型別的純值 provider(如 string 或 number),再依靠 inject 的位置重新組合,不如一開始就把相關設定聚合成一個具名物件。
想像一下,如果我們需要注入資料庫連接設定,原本的做法可能會傳入一長串參數:
useFactory(
host: string,
port: number,
username: string,
password: string,
timeout: number,
) => new DatabaseConnection(...)
較佳的做法是將這些相關欄位收斂成一個 DatabaseOptions 介面,並註冊為單一 provider:
interface DatabaseOptions {
host: string;
port: number;
username: string;
password: string;
timeout: number;
}
並讓這組設定以單一 provider 存在:
{
provide: 'DATABASE_OPTIONS',
useValue: {
host: 'localhost',
port: 5432,
username: 'app',
password: 'secret',
timeout: 5000,
} satisfies DatabaseOptions,
}
後續建立連線時,就只需要注入這單一物件:
{
provide: 'DATABASE_CONNECTION',
inject: ['DATABASE_OPTIONS'],
useFactory: (options: DatabaseOptions) =>
new DatabaseConnection(options),
}
這時 factory 只有一個位置參數,欄位之間的對應改由 options.host、options.port 這類具名屬性表達,不再依賴多個參數的位置順序。
當 factory 開始包含預設值、條件判斷或多步驟初始化時,可以改用 class-based provider:
@Injectable()
export class PostConfigService implements PostConfig {
constructor(
@Inject('POST_API_BASE_URL')
readonly apiBaseUrl: string,
@Inject('DEFAULT_AUTHOR')
readonly defaultAuthor: string,
) {}
}
改用此解法的最大好處,在於 @Inject() 裝飾器能直接與建構子參數綁定在一起,不必再同時維護 inject 陣列與 factory 參數列這兩份順序清單。
簡而言之,單純且靜態的設定組裝適合用 useFactory;一旦需要封裝業務邏輯或狀態規則時,直接升級為 class 會更加穩健。
當多個依賴的型別相同時,即使參數位置放反,TypeScript 的靜態檢查也無法察覺。針對這類重要設定,我們可以在物件建立時加入 runtime validation:
const postConfigSchema = z.object({
apiBaseUrl: z.url(),
defaultAuthor: z.string().min(1),
});
useFactory: (source: PostConfigSource): PostConfig =>
postConfigSchema.parse({
apiBaseUrl: source.apiBaseUrl,
defaultAuthor: source.defaultAuthor,
})
這樣 apiBaseUrl 若拿到 'YUN',應用會在初始化 provider 時直接失敗,而不是等到第一次發送 HTTP 請求才暴露問題。
需注意的是,Schema 驗證只能幫我們攔截「錯位後違反格式規則(如將非 URL 字串傳給 API Base URL)」的情況;如果兩個欄位的驗證規則完全一致,仍必須仰賴前述的架構設計與單元測試來發揮防禦作用。
inject 是位置參數清單:Nest 依照索引把解析結果傳進 useFactory,不會根據參數名稱重新配對。string 時,TypeScript、應用啟動與回傳結構都可能正常,只有資料內容放錯位置。